/* Copyright (c) 2008-2010, developers of the Ascension Log Visualizer
 *
 * Permission is hereby granted, free of charge, to any person
 * obtaining a copy of this software and associated documentation
 * files (the "Software"), to deal in the Software without
 * restriction, including without limitation the rights to use,
 * copy, modify, merge, publish, distribute, sublicense, and/or
 * sell copies of the Software, and to permit persons to whom
 * the Software is furnished to do so, subject to the following
 * conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 * OTHER DEALINGS IN THE SOFTWARE.
 */

package com.googlecode.logVisualizer.logData;

import java.util.*;

import com.googlecode.logVisualizer.logData.consumables.Consumable;
import com.googlecode.logVisualizer.logData.logSummary.LevelData;
import com.googlecode.logVisualizer.logData.logSummary.LogSummaryData;
import com.googlecode.logVisualizer.logData.turn.SingleTurn;
import com.googlecode.logVisualizer.logData.turn.TurnInterval;
import com.googlecode.logVisualizer.logData.turn.turnAction.*;
import com.googlecode.logVisualizer.util.DataNumberPair;

/**
 * This class is basically the representation of an ascension log. It can hold
 * all the important data accumulated during an ascension.
 * <p>
 * All methods in this class throw a {@link NullPointerException} if a null
 * object reference is passed in any parameter.
 */
public final class LogDataHolder {
    private String logName;

    // A little ugly, but since the TreeSet only checks based on compareTo() and
    // not equals() and the TurnInterval compareTo() method shouldn't be changed
    // just to make it easier to use in a TreeSet, this Comparator is used
    // instead. It should be good enough for this purpose.
    private final SortedSet<TurnInterval> turnsSpent = new TreeSet<TurnInterval>(new Comparator<TurnInterval>() {

        public int compare(
                           final TurnInterval o1, final TurnInterval o2) {
            // Compare start turns
            final int startTurnComparison = o1.getStartTurn() - o2.getStartTurn();

            // Return value, or if that fails, compare end turns.
            return startTurnComparison != 0 ? startTurnComparison : o1.getEndTurn()
                                                                    - o2.getEndTurn();
        }
    });

    private final SortedMap<Integer, FamiliarChange> familiarChanges = new TreeMap<Integer, FamiliarChange>();

    private final SortedMap<Integer, DayChange> dayChanges = new TreeMap<Integer, DayChange>();

    private final SortedMap<Integer, LevelData> levels = new TreeMap<Integer, LevelData>();

    private final SortedMap<Integer, PlayerSnapshot> playerSnapshots = new TreeMap<Integer, PlayerSnapshot>();

    private final SortedMap<Integer, EquipmentChange> equipmentChanges = new TreeMap<Integer, EquipmentChange>();

    private final List<Pull> pulls = new ArrayList<Pull>(100);

    private final List<DataNumberPair<String>> lostCombats = new ArrayList<DataNumberPair<String>>();

    private CharacterClass characterClass = CharacterClass.NOT_DEFINED;

    private ParsedLogClass parsedLogCreator = ParsedLogClass.NOT_DEFINED;

    private LogSummaryData logSummary;

    public LogDataHolder() {
        // The start of an ascension is always on day 1.
        dayChanges.put(1, new DayChange(1, 0));
        // A new ascension starts at level 1.
        levels.put(1, new LevelData(1, 0));
        // You don't have anything equipped at the start of an ascension.
        equipmentChanges.put(0, new EquipmentChange(0));
        // The familiar is not known at the very start of an ascension.
        familiarChanges.put(0, new FamiliarChange("none", 0));
        // A dummy turn for the start of an ascension.
        addTurnSpent(new SingleTurn("Ascension Start",
                                    "Ascension Start",
                                    0,
                                    getLastEquipmentChange(),
                                    getLastFamiliarChange()));
    }

    /**
     * Creates the log summary from the data of this log.
     * <p>
     * Calling this method should be done after all data additions are finished
     * (through parsing a log or otherwise). Otherwise, the summary will be
     * incomplete.
     */
    public void createLogSummary() {
        logSummary = new LogSummaryData(this);
    }

    /**
     * @return A summary of various parts of this ascension log.
     * @throws IllegalStateException
     *             if this method is called before a log summary is created by
     *             calling {@link #createLogSummary()}
     */
    public LogSummaryData getLogSummary() {
        if (logSummary == null)
            throw new IllegalStateException("Log summary has to be created first.");

        return logSummary;
    }

    /**
     * Set the name of this ascension log. The format should be
     * CharacterName-StartdateOfAscension.
     * 
     * @param logName
     *            The name of this log.
     */
    public void setLogName(
                           final String logName) {
        if (logName == null)
            throw new NullPointerException("Log name must not be null.");

        this.logName = logName;
    }

    /**
     * The name of this ascension log. The Format <b>usually</b> is:
     * CharacterName-StartdateOfAscension
     * 
     * @return The name of this ascension log.
     */
    public String getLogName() {
        return logName;
    }

    /**
     * @param turnInterval
     *            The turn interval to add.
     */
    public void addTurnsSpent(
                              final TurnInterval turnInterval) {
        if (turnInterval == null)
            throw new NullPointerException("Turn interval must not be null.");

        // Remove any existing turn interval in the set that is equal to
        // turnInterval as defined by the comparator of turnsSpent.
        if (turnsSpent.contains(turnInterval)) {
            // Add all the data from the interval which should be removed to the
            // interval that comes before it, or if there is none, to the new
            // interval so the data is not lost.
            final SortedSet<TurnInterval> tmp = turnsSpent.headSet(turnInterval);
            final TurnInterval equalInterval = turnsSpent.tailSet(turnInterval).first();
            if (!tmp.isEmpty())
                tmp.last().addTurnIntervalData(equalInterval);
            else
                turnInterval.addTurnIntervalData(equalInterval);

            turnsSpent.remove(equalInterval);
        } else if (!turnsSpent.isEmpty()) {
            final TurnInterval lastInterval = turnsSpent.last();
            if (lastInterval.getAreaName().equals(turnInterval.getAreaName())) {
                for (final SingleTurn st : turnInterval.getTurns())
                    lastInterval.addTurn(st);

                lastInterval.addNotes(turnInterval.getNotes());
                lastInterval.incrementSuccessfulFreeRunaways(turnInterval.getFreeRunaways()
                                                                         .getNumberOfSuccessfulRunaways());
                return;
            }
        }

        turnsSpent.add(turnInterval);
    }

    /**
     * Add the given turn to the log data.
     * <p>
     * Note that the integrity of the log data cannot be guaranteed if the given
     * single turn would be entered right in the middle of already existing turn
     * intervals. ({@code turn.getTurnNumber() >
     * getTurnsSpent().first().getStartTurn() && turn.getTurnNumber() <
     * getTurnsSpent().last().getEndTurn()})
     * 
     * @param turn
     *            The single turn to add.
     */
    public void addTurnSpent(
                             final SingleTurn turn) {
        if (turn == null)
            throw new NullPointerException("Turn must not be null.");

        // 1. If the turn rundown collection isn't empty and the last turn
        // interval's area name is equal to that of the given single turn, add
        // the turn to the last interval.
        // 2. If the last turn interval ends on the same turn number as the
        // given turn, remove the last turn from the last turn interval, make
        // sure the integrity of the turn interval collection is not compromised
        // and then proceed as stated in 3..
        // 3. Otherwise create a new interval and add it to the turn rundown
        // collection.
        if (!turnsSpent.isEmpty()) {
            TurnInterval lastInterval = turnsSpent.last();

            // Check for turn count errors as they sometimes occur in mafia
            // logs. If one is found, fully remove the intervals that seem to be
            // out of place in the turn interval collection.
            while (lastInterval != null && turn.getTurnNumber() - lastInterval.getEndTurn() <= -100) {
                turnsSpent.remove(lastInterval);
                lastInterval = turnsSpent.last();
            }

            if (lastInterval != null)
                if (lastInterval.getAreaName().equals(turn.getAreaName())) {
                    lastInterval.addTurn(turn);
                    return;
                } else if (lastInterval.getEndTurn() == turn.getTurnNumber()) {
                    final SingleTurn equalTurn = lastInterval.removeLastTurn();

                    // If the skipped turn was a runaway and the Navel Ring was
                    // equipped, it means that it was a successful usage of the
                    // Navel Ring.
                    if (equalTurn.isRanAwayOnThisTurn() && equalTurn.isNavelRingEquipped())
                        lastInterval.incrementSuccessfulFreeRunaways(1);

                    if (lastInterval.getTurns().isEmpty()) {
                        turn.addSingleTurnData(equalTurn);
                        turnsSpent.remove(lastInterval);

                        // The removed interval may contain free runaways
                        // usages.
                        if (!turnsSpent.isEmpty())
                            turnsSpent.last()
                                      .incrementSuccessfulFreeRunaways(lastInterval.getFreeRunaways()
                                                                                   .getNumberOfSuccessfulRunaways());
                    }
                }
        }

        addTurnsSpent(new TurnInterval(turn));
    }

    /**
     * Returns a sorted set of all turn intervals of this ascension log.
     * <p>
     * Note that the given set and its contents is directly backed by the
     * internal collections of this class. This means that changing elements
     * will in the same way effect the internal collections. In general, it is
     * best not to change any elements, but if it is done, it must not change
     * the ordering of this set, otherwise the change will corrupt it.
     * <p>
     * Also, note that the returned collection is read-only.
     * 
     * @return The turns spent.
     */
    public SortedSet<TurnInterval> getTurnsSpent() {
        return Collections.unmodifiableSortedSet(turnsSpent);
    }

    /**
     * Returns a sub interval of this LogDataHolder that includes all turn
     * intervals and other data that is inside the given interval (both {@code
     * startTurn} and {@code endTurn} are inclusive).
     * <p>
     * Note that turn intervals that start before the interval, but end inside
     * it will be included in the returned LogDataHolder. The same is true for
     * turn intervals that start inside the interval and end outside it.
     * 
     * @param startTurn
     *            The start of the interval.
     * @param endTurn
     *            The end of the interval.
     * @return A LogDataHolder including all the data inside the given start and
     *         end points.
     * @throws IllegalArgumentException
     *             if {@code endTurn} is not greater than {@code startTurn}; if
     *             {@code endTurn} is not greater than 0
     */
    public LogDataHolder getSubIntervalLogData(
                                               final int startTurn, final int endTurn) {
        if (endTurn <= startTurn)
            throw new IllegalArgumentException("The end turn must be greater than the start turn.");
        if (endTurn <= 0)
            throw new IllegalArgumentException("The end turn must be greater than zero.");

        final LogDataHolder subLog = new LogDataHolder();
        subLog.logName = logName;
        subLog.parsedLogCreator = parsedLogCreator;
        subLog.characterClass = characterClass;
        // Remove "Ascension Start" turn interval that is always included in a
        // newly created LogDataHolder.
        subLog.turnsSpent.clear();

        // Add turn intervals.
        for (final TurnInterval ti : turnsSpent) {
            // Stop the iteration once we are outside the interval
            if (ti.getStartTurn() > endTurn)
                break;

            // Both start and end are inside the interval
            if (ti.getStartTurn() >= startTurn && ti.getEndTurn() <= endTurn)
                subLog.turnsSpent.add(ti);

            // The start is not, but the end is inside the interval
            if (ti.getEndTurn() <= endTurn && ti.getEndTurn() > startTurn)
                subLog.turnsSpent.add(ti);

            // The start is inside the interval, but the end is not
            if (ti.getStartTurn() >= startTurn && ti.getStartTurn() < endTurn)
                subLog.turnsSpent.add(ti);
        }

        // Add familiar changes
        final FamiliarChange famChange = getLastFamiliarChangeBeforeTurn(startTurn);
        if (famChange != null)
            subLog.addFamiliarChange(famChange);
        for (final FamiliarChange fc : getFamiliarChanges()) {
            // Stop the iteration once we are outside the interval
            if (fc.getTurnNumber() > endTurn)
                break;

            // Add if inside the interval
            if (fc.getTurnNumber() >= startTurn && fc.getTurnNumber() <= endTurn)
                subLog.addFamiliarChange(fc);
        }

        // Add day changes
        for (final DayChange dc : getDayChanges()) {
            // Stop the iteration once we are outside the interval
            if (dc.getTurnNumber() > endTurn)
                break;

            // Add if inside the interval
            if (dc.getTurnNumber() >= startTurn && dc.getTurnNumber() < endTurn)
                subLog.addDayChange(dc);
        }

        // Add level changes
        LevelData lastLevelBeforeInterval = null;
        for (final LevelData ld : getLevels()) {
            // Stop the iteration once we are outside the interval
            if (ld.getLevelReachedOnTurn() > endTurn)
                break;

            if (ld.getLevelReachedOnTurn() < startTurn)
                lastLevelBeforeInterval = ld;

            // Add if inside the interval
            if (ld.getLevelReachedOnTurn() >= startTurn && ld.getLevelReachedOnTurn() <= endTurn)
                subLog.addLevel(ld);
        }
        if (lastLevelBeforeInterval != null)
            subLog.addLevel(lastLevelBeforeInterval);

        // Add player snapshots
        final PlayerSnapshot playSnap = getLastPlayerSnapshotBeforeTurn(startTurn);
        if (playSnap != null)
            subLog.addPlayerSnapshot(playSnap);
        for (final PlayerSnapshot ps : getPlayerSnapshots()) {
            // Stop the iteration once we are outside the interval
            if (ps.getTurnNumber() > endTurn)
                break;

            // Add if inside the interval
            if (ps.getTurnNumber() >= startTurn && ps.getTurnNumber() < endTurn)
                subLog.addPlayerSnapshot(ps);
        }

        // Add equipment changes
        final EquipmentChange equipChange = getLastEquipmentChangeBeforeTurn(startTurn);
        if (equipChange != null)
            subLog.addEquipmentChange(equipChange);
        for (final EquipmentChange ec : getEquipmentChanges()) {
            // Stop the iteration once we are outside the interval
            if (ec.getTurnNumber() > endTurn)
                break;

            // Add if inside the interval
            if (ec.getTurnNumber() > startTurn && ec.getTurnNumber() < endTurn)
                subLog.addEquipmentChange(ec);
        }

        // Add pulls
        final Set<Integer> includedDays = new HashSet<Integer>();
        for (final DayChange dc : subLog.getDayChanges())
            includedDays.add(dc.getDayNumber());
        for (final Pull p : getPulls()) {
            // Stop the iteration once we are outside the interval
            if (p.getTurnNumber() > endTurn)
                break;

            // Add if inside the interval
            if (p.getTurnNumber() >= startTurn && p.getTurnNumber() <= endTurn
                && includedDays.contains(p.getDayNumber()))
                subLog.addPull(p);
        }

        // Add lost combats
        for (final DataNumberPair<String> dnp : getLostCombats()) {
            final int turnNumber = dnp.getNumber();
            // Stop the iteration once we are outside the interval
            if (turnNumber > endTurn)
                break;

            // Add if inside the interval
            if (turnNumber >= startTurn && turnNumber <= endTurn)
                subLog.addLostCombat(dnp);
        }

        // Create log summary based on the sub interval
        subLog.createLogSummary();

        return subLog;
    }

    /**
     * @param familiarChange
     *            The familiar change to add.
     */
    public void addFamiliarChange(
                                  final FamiliarChange familiarChange) {
        if (familiarChange == null)
            throw new NullPointerException("Familiar change must not be null.");

        final Integer turnNumber = Integer.valueOf(familiarChange.getTurnNumber());

        // Only the last familiar change of a turn should be saved.
        familiarChanges.remove(turnNumber);

        // If the new familiar change is to a familiar that was already used
        // before the change, do not add the familiar change, because it would
        // be redundant.
        if (familiarChanges.isEmpty()
            || !getLastFamiliarChange().getFamiliarName().equals(familiarChange.getFamiliarName()))
            familiarChanges.put(turnNumber, familiarChange);
    }

    /**
     * Returns a sorted collection of all familiar changes of this ascension
     * log.
     * <p>
     * Note that the given collection and its contents is directly backed by the
     * internal collections of this class. This means that changing elements
     * will in the same way effect the internal collections. To ensure that the
     * underlying collection is not corrupted, the returned collection is
     * read-only.
     * 
     * @return The familiar changes.
     */
    public Collection<FamiliarChange> getFamiliarChanges() {
        return Collections.unmodifiableCollection(familiarChanges.values());
    }

    /**
     * @return The last familiar change of this ascension. Returns {@code null}
     *         if there are no familiar changes.
     */
    public FamiliarChange getLastFamiliarChange() {
        return familiarChanges.isEmpty() ? null : familiarChanges.get(familiarChanges.lastKey());
    }

    /**
     * @param turn
     *            The turn number before which the last familiar change should
     *            be returned of.
     * @return The last familiar change before the given turn of this ascension.
     *         Returns {@code null} if there are no such familiar changes.
     * @throws IllegalArgumentException
     *             if turn is negative.
     */
    public FamiliarChange getLastFamiliarChangeBeforeTurn(
                                                          final int turn) {
        if (turn < 0)
            throw new IllegalArgumentException("Turn number cannot be negative.");

        return getLastElementBeforeInteger(familiarChanges, Integer.valueOf(turn));
    }

    /**
     * @param turn
     *            The turn number after which the first familiar change should
     *            be returned of.
     * @return The first familiar change on or after the given turn of this
     *         ascension. Returns {@code null} if there are no such familiar
     *         changes.
     * @throws IllegalArgumentException
     *             if turn is negative.
     */
    public FamiliarChange getFirstFamiliarChangeAfterTurn(
                                                          final int turn) {
        if (turn < 0)
            throw new IllegalArgumentException("Turn number cannot be negative.");

        return getFirstElementAfterInteger(familiarChanges, Integer.valueOf(turn));
    }

    /**
     * @param dayChange
     *            The day change to add.
     */
    public void addDayChange(
                             final DayChange dayChange) {
        if (dayChange == null)
            throw new NullPointerException("Day change must not be null.");

        dayChanges.put(dayChange.getDayNumber(), dayChange);
    }

    /**
     * Returns a sorted collection of all day changes of this ascension log.
     * <p>
     * Note that the given collection and its contents is directly backed by the
     * internal collections of this class. This means that changing elements
     * will in the same way effect the internal collections. To ensure that the
     * underlying collection is not corrupted, the returned collection is
     * read-only.
     * 
     * @return The day changes.
     */
    public Collection<DayChange> getDayChanges() {
        return Collections.unmodifiableCollection(dayChanges.values());
    }

    /**
     * @return The last day change of this ascension. Returns {@code null} if
     *         there are no day changes.
     */
    public DayChange getLastDayChange() {
        return dayChanges.isEmpty() ? null : dayChanges.get(dayChanges.lastKey());
    }

    /**
     * @param turnNumber
     *            The turn number specifying the point of which the day is
     *            wanted.
     * @return The day of the given turn number.
     * @throws IllegalArgumentException
     *             if turn is negative.
     */
    public DayChange getCurrentDay(
                                   final int turnNumber) {
        if (turnNumber < 0)
            throw new IllegalArgumentException("Turn number cannot be negative.");

        // Initialise with day 1, because it is always present.
        DayChange currentDay = dayChanges.get(Integer.valueOf(1));
        for (final DayChange day : getDayChanges()) {
            // If the turn number of the day change is higher than the specified
            // turn number, stop the loop.
            if (day.getTurnNumber() > turnNumber)
                break;

            // As long as loop isn't stopped, the checked day change happened
            // before the given turn number.
            currentDay = day;
        }

        return currentDay;
    }

    /**
     * @param level
     *            The level data to add.
     */
    public void addLevel(
                         final LevelData level) {
        if (level == null)
            throw new NullPointerException("Level must not be null.");

        levels.put(level.getLevelNumber(), level);
    }

    /**
     * Returns a sorted collection of all level data of this ascension log.
     * <p>
     * Note that the given collection and its contents is directly backed by the
     * internal collections of this class. This means that changing elements
     * will in the same way effect the internal collections. To ensure that the
     * underlying collection is not corrupted, the returned collection is
     * read-only.
     * 
     * @return The level data.
     */
    public Collection<LevelData> getLevels() {
        return Collections.unmodifiableCollection(levels.values());
    }

    /**
     * @return The last level reached of this ascension. Returns {@code null} if
     *         there are no levels reached.
     */
    public LevelData getLastLevel() {
        return levels.isEmpty() ? null : levels.get(levels.lastKey());
    }

    /**
     * @param turnNumber
     *            The turn number specifying the point of which the level is
     *            wanted.
     * @return The level reached at the given turn number.
     * @throws IllegalArgumentException
     *             if turn is negative.
     */
    public LevelData getCurrentLevel(
                                     final int turnNumber) {
        if (turnNumber < 0)
            throw new IllegalArgumentException("Turn number cannot be negative.");

        // Initialise with level 1, because it is always present.
        LevelData currentLevel = levels.get(Integer.valueOf(1));
        for (final LevelData level : getLevels()) {
            // If the turn number of the level change is higher than the
            // specified turn number, stop the loop.
            if (level.getLevelReachedOnTurn() > turnNumber)
                break;

            // As long as loop isn't stopped, the checked level change happened
            // before the given turn number.
            currentLevel = level;
        }

        return currentLevel;
    }

    /**
     * @param playerSnapshot
     *            The player snapshot to add.
     */
    public void addPlayerSnapshot(
                                  final PlayerSnapshot playerSnapshot) {
        if (playerSnapshot == null)
            throw new NullPointerException("Player snapshot must not be null.");

        // Add the player snapshot.
        playerSnapshots.put(Integer.valueOf(playerSnapshot.getTurnNumber()), playerSnapshot);
    }

    /**
     * Returns a sorted collection of all player snapshots of this ascension
     * log.
     * <p>
     * Note that the given collection and its contents is directly backed by the
     * internal collections of this class. This means that changing elements
     * will in the same way effect the internal collections. To ensure that the
     * underlying collection is not corrupted, the returned collection is
     * read-only.
     * 
     * @return The player snapshots.
     */
    public Collection<PlayerSnapshot> getPlayerSnapshots() {
        return Collections.unmodifiableCollection(playerSnapshots.values());
    }

    /**
     * @return The last player snapshot of this ascension. Returns {@code null}
     *         if there are no player snapshots.
     */
    public PlayerSnapshot getLastPlayerSnapshot() {
        return playerSnapshots.isEmpty() ? null : playerSnapshots.get(playerSnapshots.lastKey());
    }

    /**
     * @param turn
     *            The turn number before which the last player snapshot should
     *            be returned of.
     * @return The last player snapshot before the given turn of this ascension.
     *         Returns {@code null} if there are no such player snapshots.
     * @throws IllegalArgumentException
     *             if turn is negative.
     */
    public PlayerSnapshot getLastPlayerSnapshotBeforeTurn(
                                                          final int turn) {
        if (turn < 0)
            throw new IllegalArgumentException("Turn number cannot be negative.");

        return getLastElementBeforeInteger(playerSnapshots, Integer.valueOf(turn));
    }

    /**
     * @param turn
     *            The turn number after which the first player snapshot should
     *            be returned of.
     * @return The first player snapshot on or after the given turn of this
     *         ascension. Returns {@code null} if there are no such player
     *         snapshots.
     * @throws IllegalArgumentException
     *             if turn is negative.
     */
    public PlayerSnapshot getFirstPlayerSnapshotAfterTurn(
                                                          final int turn) {
        if (turn < 0)
            throw new IllegalArgumentException("Turn number cannot be negative.");

        return getFirstElementAfterInteger(playerSnapshots, Integer.valueOf(turn));
    }

    /**
     * @param equipmentChange
     *            The equipment change to add.
     */
    public void addEquipmentChange(
                                   final EquipmentChange equipmentChange) {
        if (equipmentChange == null)
            throw new NullPointerException("Equipment change must not be null.");

        // Add the equipment change.
        equipmentChanges.put(Integer.valueOf(equipmentChange.getTurnNumber()), equipmentChange);
    }

    /**
     * Returns a sorted collection of all equipment changes of this ascension
     * log.
     * <p>
     * Note that the given collection and its contents is directly backed by the
     * internal collections of this class. This means that changing elements
     * will in the same way effect the internal collections. To ensure that the
     * underlying collection is not corrupted, the returned collection is
     * read-only.
     * 
     * @return The equipment changes.
     */
    public Collection<EquipmentChange> getEquipmentChanges() {
        return Collections.unmodifiableCollection(equipmentChanges.values());
    }

    /**
     * @return The last equipment change of this ascension. Returns {@code null}
     *         if there are no equipment changes.
     */
    public EquipmentChange getLastEquipmentChange() {
        return equipmentChanges.isEmpty() ? null : equipmentChanges.get(equipmentChanges.lastKey());
    }

    /**
     * @param turn
     *            The turn number before which the last equipment change should
     *            be returned of.
     * @return The last equipment change before the given turn of this
     *         ascension. Returns {@code null} if there are no such equipment
     *         changes.
     * @throws IllegalArgumentException
     *             if turn is negative.
     */
    public EquipmentChange getLastEquipmentChangeBeforeTurn(
                                                            final int turn) {
        if (turn < 0)
            throw new IllegalArgumentException("Turn number cannot be negative.");

        return getLastElementBeforeInteger(equipmentChanges, Integer.valueOf(turn));
    }

    /**
     * @param turn
     *            The turn number after which the first equipment change should
     *            be returned of.
     * @return The first equipment change on or after the given turn of this
     *         ascension. Returns {@code null} if there are no such equipment
     *         changes.
     * @throws IllegalArgumentException
     *             if turn is negative.
     */
    public EquipmentChange getFirstEquipmentChangeAfterTurn(
                                                            final int turn) {
        if (turn < 0)
            throw new IllegalArgumentException("Turn number cannot be negative.");

        return getFirstElementAfterInteger(equipmentChanges, Integer.valueOf(turn));
    }

    /**
     * @param map
     *            The sorted map in which the element should be looked for.
     * @param number
     *            The number before which the last element in the map should be
     *            returned of.
     * @return The last element before the given number. Returns {@code null} if
     *         there is no such element.
     */
    private static <V> V getLastElementBeforeInteger(
                                                     final SortedMap<Integer, V> map,
                                                     final Integer number) {
        final SortedMap<Integer, V> headMap = map.headMap(number);

        return headMap.isEmpty() ? null : headMap.get(headMap.lastKey());
    }

    /**
     * @param map
     *            The sorted map in which the element should be looked for.
     * @param number
     *            The number on or after which the last element in the map
     *            should be returned of.
     * @return The last element before the given number. Returns {@code null} if
     *         there is no such element.
     */
    private static <V> V getFirstElementAfterInteger(
                                                     final SortedMap<Integer, V> map,
                                                     final Integer number) {
        final SortedMap<Integer, V> tailMap = map.tailMap(number);

        return tailMap.isEmpty() ? null : tailMap.get(tailMap.firstKey());
    }

    /**
     * @param pull
     *            The pull to add.
     */
    public void addPull(
                        final Pull pull) {
        if (pull == null)
            throw new NullPointerException("Pull must not be null.");

        pulls.add(pull);
    }

    /**
     * Returns the pull list. This list is empty if no pulls were made.
     * <p>
     * Note that while this list usually is sorted after the turn numbers of the
     * pulls, this cannot be guaranteed.
     * <p>
     * Also, please note that the given list and its contents is directly backed
     * by the internal collections of this class. This means that changing
     * elements will in the same way effect the internal collections. Therefore,
     * great care should be taken when working with this collection. To ensure
     * that the underlying collection is not corrupted through remove or add
     * operations, the returned collection is read-only.
     * 
     * @return The pulls.
     */
    public List<Pull> getPulls() {
        return Collections.unmodifiableList(pulls);
    }

    /**
     * @param lostCombat
     *            The lost combat to add.
     */
    public void addLostCombat(
                              final DataNumberPair<String> lostCombat) {
        if (lostCombat == null)
            throw new NullPointerException("Lost combat must not be null.");

        lostCombats.add(lostCombat);
    }

    /**
     * Returns a list of all lost combats. This list is empty if no combats were
     * lost.
     * <p>
     * Note that while this list usually is sorted after the turn numbers of the
     * combats, this cannot be guaranteed.
     * <p>
     * Also, please note that the given list is read-only.
     * 
     * @return The lost combats.
     */
    public List<DataNumberPair<String>> getLostCombats() {
        return Collections.unmodifiableList(lostCombats);
    }

    /**
     * Returns a list of all dropped items during this ascension. The list is
     * not sorted.
     * <p>
     * Note that this a convenience method. It is equal to {@code
     * logData.getLogSummary().getDroppedItems()}.
     * 
     * @return A list of all dropped items during this ascension.
     */
    public List<Item> getDroppedItems() {
        return logSummary.getDroppedItems();
    }

    /**
     * Returns a list of all skills cast during this ascension.
     * <p>
     * Note that this a convenience method. It is equal to {@code
     * logData.getLogSummary().getSkillsCast()}.
     * 
     * @return A list of all skills cast during this ascension.
     */
    public List<Skill> getAllSkillsCast() {
        return logSummary.getSkillsCast();
    }

    /**
     * Returns a list of all consumables used during this ascension.
     * <p>
     * Note that this a convenience method. It is equal to {@code
     * logData.getLogSummary().getAllConsumablesUsed()}.
     * 
     * @return A list of all consumables used during this ascension.
     */
    public List<Consumable> getAllConsumablesUsed() {
        return logSummary.getAllConsumablesUsed();
    }

    /**
     * This method will set the character class of this ascension log based on
     * the given string. If the string doesn't match any of the character class
     * names, the character class of this ascension log will be set to {@code
     * NOT_DEFINED}.
     * 
     * @param characterClassName
     *            The name of the character class to set.
     */
    public void setCharacterClass(
                                  final String characterClassName) {
        characterClass = CharacterClass.fromString(characterClassName);
    }

    /**
     * @return The character class of this ascension log. If no character class
     *         has been specified this method will return {@code NOT_DEFINED}.
     */
    public CharacterClass getCharacterClass() {
        return characterClass;
    }

    /**
     * @param parsedLogCreator
     *            The program which created the parsed ascension log behind this
     *            LogDataHolder to set.
     */
    public void setParsedLogCreator(
                                    final ParsedLogClass parsedLogCreator) {
        if (parsedLogCreator == null)
            throw new NullPointerException("The parsed log creator must not be null.");

        this.parsedLogCreator = parsedLogCreator;
    }

    /**
     * If this LogDataHolder hasn't been created by a parsed ascension log or
     * the log creator hasn't been set (most probably because it couldn't be
     * determined), this method should return {@code NOT_DEFINED}.
     * 
     * @return The program which created the parsed ascension log behind this
     *         LogDataHolder.
     */
    public ParsedLogClass getParsedLogCreator() {
        return parsedLogCreator;
    }

    /**
     * This enumeration represents all six character classes.
     */
    public static enum CharacterClass {
        SEAL_CLUBBER("Seal Clubber", StatClass.MUSCLE),
        TURTLE_TAMER("Turtle Tamer", StatClass.MUSCLE),
        PASTAMANCER("Pastamancer", StatClass.MYSTICALITY),
        SAUCEROR("Sauceror", StatClass.MYSTICALITY),
        DISCO_BANDIT("Disco Bandit", StatClass.MOXIE),
        ACCORDION_THIEF("Accordion Thief", StatClass.MOXIE),
        NOT_DEFINED("not defined", StatClass.MUSCLE);

        private static final Map<String, CharacterClass> stringToEnum = new HashMap<String, CharacterClass>();

        static {
            for (final CharacterClass op : values())
                stringToEnum.put(op.toString(), op);
        }

        private final String className;

        private final StatClass statClass;

        CharacterClass(
                       final String className, final StatClass statClass) {
            this.className = className;
            this.statClass = statClass;
        }

        /**
         * @return The mainstat of this character class.
         */
        public StatClass getStatClass() {
            return statClass;
        }

        @Override
        public String toString() {
            return className;
        }

        /**
         * @return The enum whose toString method returns a string which is
         *         equal to the given string. If no match is found this method
         *         will return {@code NOT_DEFINED}.
         */
        public static CharacterClass fromString(
                                                final String className) {
            if (className == null)
                throw new NullPointerException("Class name must not be null.");

            final CharacterClass characterClass = stringToEnum.get(className);

            return characterClass != null ? characterClass : NOT_DEFINED;
        }
    }

    /**
     * This enumeration represents the three stat classes.
     */
    public static enum StatClass {
        MUSCLE, MYSTICALITY, MOXIE;
    }

    /**
     * This enumeration represents the different parsers which could have
     * created a parsed ascension log.
     */
    public static enum ParsedLogClass {
        LOG_VISUALIZER, AFH_PARSER, NOT_DEFINED;
    }
}
